1/**
2 * Static Open Graph image endpoint. During `astro build` Astro invokes this
3 * for every entry returned by `getStaticPaths`, writing a WebP into
4 * `dist/og/<slug>.webp`. Pages reference these via `<meta property="og:image">`
5 * in their layout/head.
6 *
7 * - Marketing pages (top-level `src/pages/*.{astro,mdx}`) → /og/<page>.webp
8 * (the homepage is keyed as `index`).
9 * - Starlight docs (`getCollection("docs")`) → /og/<entry.slug>.webp.
10 *
11 * The card itself lives in `src/brand/OgCard.tsx` and is rendered via
12 * Takumi. Fonts are the same families used on the website
13 * (`tokens.css`), loaded from npm bundles with full Source Serif glyph coverage —
14 * arrows, em-dashes, fancy quotes, etc. all render verbatim.
15 */
16
17import type { APIRoute, GetStaticPaths } from "astro";
18import { getCollection } from "astro:content";
19import { createHash } from "node:crypto";
20import type { ReactNode } from "react";
21import { renderToStaticMarkup } from "react-dom/server";
22import { jsx } from "react/jsx-runtime";
23import { render } from "takumi-js";
24import { brandFonts } from "../../brand/fonts.ts";
25import {
26 OgCard,
27 type OgCardKind,
28 type OgCardProps,
29} from "../../brand/OgCard.tsx";
30
31interface Entry extends OgCardProps {
32 slug: string;
33}
34
35const RENDER_CONCURRENCY = 4;
36
37const renderOptions = {
38 width: 1200 * 2,
39 height: 630 * 2,
40 devicePixelRatio: 2,
41 format: "webp" as const,
42 emoji: "from-font" as const,
43};
44
45async function renderCard(entry: Entry): Promise<Uint8Array<ArrayBuffer>> {
46 return render(OgCard(entry), { ...renderOptions, fonts: brandFonts });
47}
48
49/**
50 * Astro's incremental builder generates routes serially, so a cold build would
51 * otherwise leave all but one native renderer idle. Keep a bounded window of
52 * upcoming cards in flight: cache hits do no rendering at all, while cold builds
53 * still use concurrent native rendering. Entries outside the window are never speculatively
54 * rendered.
55 */
56let renderEntries: Entry[] = [];
57const renderEntryIndexes = new Map<string, number>();
58const pendingRenders = new Map<string, Promise<Uint8Array<ArrayBuffer>>>();
59
60function setRenderEntries(entries: Entry[]) {
61 renderEntries = entries;
62 renderEntryIndexes.clear();
63 for (const [index, entry] of entries.entries()) {
64 renderEntryIndexes.set(entry.slug, index);
65 }
66}
67
68function renderWithLookahead(entry: Entry) {
69 const start = renderEntryIndexes.get(entry.slug);
70 if (start === undefined) return renderCard(entry);
71
72 const size = import.meta.env.DEV ? 1 : RENDER_CONCURRENCY;
73 for (const candidate of renderEntries.slice(start, start + size)) {
74 if (pendingRenders.has(candidate.slug)) continue;
75 const render = renderCard(candidate);
76 render.catch(() => {});
77 pendingRenders.set(candidate.slug, render);
78 }
79
80 return pendingRenders.get(entry.slug)!;
81}
82
83// ────────────────────────────────────────────────────────────────────────────
84// Page enumeration
85// ────────────────────────────────────────────────────────────────────────────
86
87/**
88 * Fallbacks for the marketing pages — these aren't in a content collection
89 * so we hand-curate their OG metadata. Keys are URL-style slugs (e.g.
90 * `index` for `/`).
91 */
92const MARKETING_PAGES: Record<string, Omit<Entry, "slug" | "kind">> = {
93 index: {
94 title: jsx("span", {
95 children: [
96 "Reach for the ",
97 jsx("span", {
98 style: { fontStyle: "italic", color: "#a8c47a" },
99 children: "better primitives.",
100 }),
101 ],
102 }),
103 description:
104 "Snap together cloud resources and let Alchemy take care of the wiring so you can focus on your product.",
105 eyebrow: "infrastructure as effects",
106 },
107 compare: {
108 title: "Alchemy vs SST, Pulumi & Terraform",
109 description:
110 "One TypeScript program for your infrastructure and the code that runs on it, with no wrapped engines underneath.",
111 eyebrow: "compare",
112 },
113 privacy: {
114 title: "Privacy & Telemetry",
115 description:
116 "What data the Alchemy CLI and Cloudflare State Store collect, where it goes, and how to opt out.",
117 eyebrow: "alchemy.run",
118 },
119};
120
121function classifyDoc(slug: string): { kind: OgCardKind; eyebrow: string } {
122 if (slug.startsWith("blog/"))
123 return { kind: "blog", eyebrow: "blog · alchemy.run" };
124 if (slug.startsWith("guides/"))
125 return { kind: "doc", eyebrow: "guide · alchemy" };
126 if (slug.startsWith("concepts/"))
127 return { kind: "doc", eyebrow: "concept · alchemy" };
128 if (slug.startsWith("tutorial/"))
129 return { kind: "doc", eyebrow: "tutorial · alchemy" };
130 if (slug.startsWith("providers/"))
131 return { kind: "doc", eyebrow: "provider · alchemy" };
132 if (slug.startsWith("compare/"))
133 return { kind: "doc", eyebrow: "compare · alchemy" };
134 return { kind: "doc", eyebrow: "alchemy · documentation" };
135}
136
137/** Hash the actual card tree, including styles and embedded SVGs, plus renderer inputs. */
138export function createOgCacheKey(
139 fonts: Awaited<typeof brandFonts>,
140 options: object,
141) {
142 const shared = createHash("sha256").update(JSON.stringify(options));
143 for (const { data, ...metadata } of fonts) {
144 shared.update(JSON.stringify(metadata));
145 shared.update(new Uint8Array(data));
146 }
147 return (card: ReactNode) =>
148 shared.copy().update(renderToStaticMarkup(card)).digest("hex");
149}
150
151export const getStaticPaths: GetStaticPaths = async () => {
152 // `DOCS_FAST=1` (the `docs:check` build target) skips OG image generation —
153 // rendering PNGs is unnecessary for link checking.
154 if (process.env.DOCS_FAST) return [];
155
156 const docs = await getCollection("docs");
157 const docPaths = docs.map((entry: any) => {
158 const slug = (entry as { slug?: string; id?: string }).slug ?? entry.id;
159 const meta = classifyDoc(slug);
160 const data = entry.data as {
161 title?: string;
162 description?: string;
163 excerpt?: string;
164 date?: string | Date;
165 };
166 return {
167 params: { slug },
168 props: {
169 slug,
170 title: data.title ?? slug,
171 // Blog frontmatter uses `excerpt` (starlight-blog schema). Fall
172 // back to it so the OG card has body copy to fill the layout.
173 description: data.description ?? data.excerpt,
174 kind: meta.kind,
175 eyebrow: meta.eyebrow,
176 date:
177 data.date instanceof Date
178 ? data.date.toISOString().slice(0, 10)
179 : data.date,
180 } satisfies Entry,
181 };
182 });
183
184 const marketingPaths = Object.entries(MARKETING_PAGES).map(
185 ([slug, meta]) => ({
186 params: { slug },
187 props: {
188 slug,
189 title: meta.title,
190 description: meta.description,
191 kind: "marketing" as const,
192 eyebrow: meta.eyebrow,
193 } satisfies Entry,
194 }),
195 );
196
197 // Virtual routes that emit og:image metas but aren't docs entries or
198 // hand-curated marketing pages: Starlight's 404 and starlight-blog's
199 // pagination indexes (/blog, /blog/2, …). The page count is estimated
200 // generously (starlight-blog paginates at ≥5 posts/page, so dividing
201 // by 5 can only overshoot); a surplus card is harmless, and the
202 // build-output og:image check fails loudly if a rendered page ever
203 // references a card this misses.
204 const blogPosts = docs.filter((entry: any) =>
205 ((entry as { slug?: string; id?: string }).slug ?? entry.id).startsWith(
206 "blog/",
207 ),
208 );
209 const blogDescription = "Release notes and posts from the alchemy team.";
210 const virtualPaths = [
211 {
212 slug: "404",
213 title: "Page not found",
214 kind: "doc" as const,
215 eyebrow: "alchemy · documentation",
216 },
217 ...Array.from(
218 { length: Math.max(1, Math.ceil(blogPosts.length / 5)) },
219 (_, i) => ({
220 slug: i === 0 ? "blog" : `blog/${i + 1}`,
221 title: "Blog",
222 description: blogDescription,
223 kind: "blog" as const,
224 eyebrow: "blog · alchemy.run",
225 }),
226 ),
227 ].map((props) => ({
228 params: { slug: props.slug },
229 props,
230 }));
231
232 const paths = [...marketingPaths, ...docPaths, ...virtualPaths];
233 setRenderEntries(paths.map(({ props }) => props));
234 const cacheKey = createOgCacheKey(brandFonts, renderOptions);
235 return paths.map((path) => ({
236 ...path,
237 cacheKey: cacheKey(OgCard(path.props)),
238 }));
239};
240
241export const GET: APIRoute = async ({ props }) => {
242 const entry = props as Entry;
243 const png = await renderWithLookahead(entry);
244 pendingRenders.delete(entry.slug);
245 return new Response(png, {
246 headers: {
247 "Content-Type": "image/webp",
248 },
249 });
250};